blog

从为什么不要把 React Hooks 写在条件语句中说起(不需要涉及源码)

Technique
calendar
clocklittle-spider-cyber
从为什么不要把 React Hooks 写在条件语句中说起,通过 React 的渲染机制解释 React 的一些奇怪规则

引子

对 React 的初学者来说,除去 useEffect 这个大坑,React 还有不少看起来有些诡异的规则,比如

  1. 不要把 React Hooks 写在条件语句中
  2. React 会保留相同位置相同类型的组件的状态
  3. 为什么数组遍历需要 key 属性

刚开始学习的时候,我只是盲目地遵从着 eslint 的报错提示,心里觉得很生硬;等熟悉 React 后,我才明白了它们的原因。接下来我将尝试分别解释这三条规则的必要性,而到最后,大家将会明白它们都是因 React 的渲染机制而起。

解释过程中我并不会引入 Fiber 以及更底层的 React 技术细节概念,因为一是我希望文章总是能被更多人阅读,尽量保持简洁,降低门槛。二是我觉得底层实现细节和这些规则实际上并没有直接关系,引入它们反而会制造噪音。对于想要了解更多(乃至从头实现一个 React)的朋友,我在文章结尾给出了一些我搜集到的拓展阅读。

React 的渲染过程

首先让我们先描述一下 React 的渲染过程。 假设我们有这样一个 Counter 组件,(摘自官方教程

export default function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
  }

  return <button onClick={handleClick}>You pressed me {count} times</button>;
}

当这个 组件函数 (function Counter)第一次执行,即 React 挂载1 Counter 组件时, 组件函数 第一次执行useState函数,执行后 React 给相对应的组件注册一个useStatehook,并初始化 state;useState 最后返回当前 state 的值,以及一个对 state 进行更改的 setState 函数。函数组件在最后返回的 JSX 将 setState 绑定到 button 的点击事件中。当用户点击 button 时,setState 触发重新渲染(rerender),React 再一次执行了这个 组件函数 ,执行过程中再一次运行useState函数,更新 state,并返回最新的值,React 将其填充到 JSX 中,最后更新视图。

整个过程最重要的其实就一点:每次用 setState 更改状态的时候,React 都会重新执行整个组件函数

为什么不要在条件判断中使用 hook

记住这点后,那首先来思考第一条规则——为什么不要在条件判断中使用 hook? 要回答这个问题,我们首先要明白,hook 的作用是什么?hook 首先是函数,回顾对 React 渲染过程的描述,我们在执行组件函数的过程中,调用了useState这个 hook 函数;随后在 rerender 的过程中, React 再次执行了组件函数,**并再次调用useState**拿到了更新后的状态。 所以关键在于,每一次触发 rerender,我们都会 重新执行一遍组件函数,组件函数的执行结果也就是 rerender 的结果

那如果从函数的执行角度来讲,前一次和后一次函数的执行结果分别是什么呢?React 看到了什么?

假设我们有这样一个表单组件(摘自 React 官方教程)

import {`useState`} from "React";

export default function Form() {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");

  const fullName = firstName + " " + lastName;

  function handleFirstNameChange(e) {
    setFirstName(e.target.value);
  }

  function handleLastNameChange(e) {
    setLastName(e.target.value);
  }

  return (
    <>
      <h2>Let’s check you in</h2>
      <label>
        First name: <input value={firstName} onChange={handleFirstNameChange} />
      </label>
      <label>
        Last name: <input value={lastName} onChange={handleLastNameChange} />
      </label>
      <p>
        Your ticket will be issued to: <b>{fullName}</b>
      </p>
    </>
  );
}

要注意,“firstName”是对useState返回的状态值的命名,而不是对状态本身命名,“firstName”并没有作为参数参与到对useState的调用当中。所以从 React 的视角来看,组件函数执行的结果是这样的。

import {`useState`} from 'React';

export default function Form() {
  useState('');
  useState('');
  ...

  return (
    <>
    ...
    </>
  );
}

从这个视角来看,能区分这两个 state 的唯一办法,就是他们对应的 useState 的 调用顺序 或者说 书写顺序 那如果在条件语句中使用 useState hook,会出现什么情况? 假设原始组件是这样:

import {`useState`} from 'React';

export default function Form() {
  if (Math.random() > 0.5) {
    const [firstName, setFirstName] = useState('');
  }
  if (Math.random() > 0.5) {
    const [lastName, setLastName] = useState('');
  }
  ...

  return (
    <>
    ...
    </>
  );
}

那执行结果可能是这样

import {`useState`} from 'React';

export default function Form() {
  useState('');
  ...

  return (
    <>
    ...
    </>
  );
}

也有可能是这样

import {`useState`} from 'React';

export default function Form() {
  useState('');
  ...

  return (
    <>
    ...
    </>
  );
}

从这个视角看,在条件判断中使用 hook 带来的问题就呼之欲出了:请问调用这唯一的useState获取的是代表"firstName"的那个状态,还是代表"lastName"的那个状态?我们没有办法判断,我们只看到了一个 useState。 不仅仅有这个限制,官方文档还列出了诸如不要在循环、嵌套函数、try/catch 代码块中使用 Hooks 的规则,这些规则被归纳为“仅在顶层调用 hooks”。它们的原因都是类似的:在多次函数执行中,区分 Hooks 的唯一办法就是它们的调用顺序,因此要避免一切 有可能 打乱顺序的行为

这里提一下,我在写这篇文章的时候查询了一些资料,其中很多都有一个大概这样的总结“因为 React 用一个链表(自制 React 则多用数组)来储存 Hooks 的状态,所以必须要保证它的调用顺序与链表/数组中的排序一致”。这个说法不能说错,但我觉得可能过于聚焦于技术细节了。问题不是 React 用什么数据结构去储存 hooks,问题在于,只要 React 每当状态变更就重新执行一遍组件函数,只要每次执行函数都会重新调用一遍 hooks,那在没有 key、id 等标识符的情况下,React 就只能凭借在函数中的调用顺序去辨认不同的 hooks。 这里提到了 key ,这也是另外两个问题的关键。

为什么 React 会保留相同位置相同类型的组件的状态

为什么 React 会保留相同位置相同类型的组件的状态?让我们看看另外一个例子(摘自官方文档),继续观察组件函数的执行结果,但这次关注返回的 JSX 部分。


export default function App() {
  const [isFancy, setIsFancy] = useState(false);
  return (
    <>
      {isFancy ? (
        <Counter isFancy={true} />
      ) : (
        <Counter isFancy={false} />
      )}
      ...
    </>
  );
}

function Counter({ isFancy }) {
  ...
  return (
    <div className={isFancy && "fancy"}>
    ...
    </div>
  );
}

App 函数的执行结果可以简化为这样(isFancy 为 true),

export default function App() {
  useState(false);
  return (
    <>
      <Counter isFancy={true} />
      ...
    </>
  );
}

function Counter({ isFancy }) {
  ...
  return (
    <div className={isFancy && "fancy"}>
    ...
    </div>
  );
}

或这样(isFancy 为 false)

export default function App() {
  useState(false);
  return (
    <>
      <Counter isFancy={false} />
      ...
    </>
  );
}

function Counter({ isFancy }) {
  ...
  return (
    <div className={isFancy && "fancy"}>
    ...
    </div>
  );
}

当 React 看到返回的这两个 JSX 时,它同样没法判断这还是不是同一个 counter2。但这个时候 React 多了另一个信息——它们的 组件名 是相同的。出于性能考虑,React 会默认这还是同一个组件,这样就可以仅更新这个组件的属性而非重新挂载这个组件。(在这个案例中, React 计算以及提交虚拟 dom 时,仅需浏览器更新 div 的 class,而非删除掉这个 div 后再重新创建并添加一个 div)

那如何让 React 认识到这并不是同一个组件呢?这就是 key 属性的作用,它作为组件的唯一标识,类似于数据库中的 id。有了它,React 就不用再借助位置、名称类型来判断组件的同一性了,所以我们可以通过设置不同的 key 来重制掉同一类型同一位置组件的状态

为什么 React 会要求开发者给 JSX 中的数组项加上 key 属性

有了前面的铺垫,我们就可以很顺利地解释第三个规则——为什么 React 会要求开发者给 JSX 中的数组项加上 key 属性?

这里首先要定两个个概念:当组件的某种状态/属性会在 react 的多次渲染中改变时,我们可以称它为 受控的,而仅受创建时初次渲染的影响,并在后续渲染中保持稳定的状态/属性,我们则称它为 非受控的

const Input = (props: Props) => {
  const [value, setValue] = useState("value");
  const ref = useRef < HTMLInputElement > null;
  useEffect(() => {
    ref.current?.focus();
  }, []);
  return (
    <input
      ref={ref}
      type="text"
      value={value}
      onChange={(e) => {
        setValue(e.target.value);
      }}
      defaultValue={props.defaultValue}
    />
  );
};

例如,在这个组件中,value 就是一个受控的属性,因为它完全受 react 的渲染更新周期控制,而 ref 所指向的 node 节点以及 focus 状态则不是,因为它们在创建完毕之后就不会再变化了。defaultValue 也是一样,即使后续 props 发生变化。本质来说,react 对组件的渲染更新就是在重新设定组件元素的属性,而有些东西(比如 node 节点,以及 focus 状态)不属于属性,有些属性即使重新设定了也不会有效果(比如 defaultValue),所以说他们是非受控的。

解释完概念后再回到列表当中。相比于其它固定的代码,用来 map 的数组在 JSX 中是一个非常不稳定的结构,它随时有可能受 state 和 prop 的影响而增减数组项,因此数组项的顺序或者说位置(index)在数组中并不是一个稳定的标识,一个数组项在两次渲染中很有可能会发生位置改变,然而由于数组项的组件类型相同,React 会错误地仅根据位置去更新数组项,新旧数组项会复用同一位置上的节点元素,这个时候,元素中的受控状态会正常更新,但非受控状态就会发生错乱,因为新的数组项依旧沿用老数组项的元素的非受控状态。React 为了避免混用新旧数组项的 dom 节点,就必须要有一个唯一而稳定的标识去区分它们,将它们与上一次快照中的数组项一一对应。

总结

其实这三个问题最后都可以归结为“React 如何比较重复执行组件函数的不同结果”,如果没有标识符 key,React 就只能根据顺序去识别不同的 Hooks 和组件。无论 React 框架底层细节是如何实现的,只要 React 遵循 每次渲染时都执行一遍组件函数来生成 UI 结果,而非把它当成一种初始化模板 的做法,那就肯定会出现这些问题。

拓展资料

  1. 掌握 React Reconciliation,用丰富的图片和动画展示 React Reconciliation,这个系列都很不错,适合新手以及很久没有温习 React 的朋友巩固知识。
  2. 搭建你自己的 React,从认识 JSX 开始搭建 React。有中文翻译,但没有更新 Hooks 篇。
  3. 深入 React Hooks 和 Fiber,通过对源码的调试更深入地解析 React 重新渲染的过程。

Footnotes

  1. 这里的 挂载(mount) 指的是 React 将组件添加到虚拟 dom 上;下文的 重新渲染(rerender) 则是指 React 在状态更新后重新计算虚拟 dom 的过程

  2. React 官方教程中有一个看起来和本文结论冲突的例子,例子中两个 Counter 看上去在同样的位置却并不共享同一个状态,这是因为其中一个语句的结果是{false},仍然占用一个位置,只是 React 帮你处理掉了而已。